# 实战篇 03:页面布局方案

本节参考代码:

  1. react-boilerplate-pro/src/layouts/BasicLayout.jsx
  2. react-boilerplate-pro/src/layouts/NormalLayout.jsx
  3. react-acl-router/src/AclRouter.jsx

在传统的前端开发中提到布局我们可能第一时间会想到「圣杯布局」或「双飞燕布局」这些跟 CSS 相关的页面布局方式。而在现代前端开发中,更准确地说在组件化开发逐渐成为现代前端开发主流之后对于布局这一概念又有了新的定义,那就是多个页面中共同的部分,也可以叫做页面的骨架。

如下图,在不同页面中都会包含的侧边栏菜单就是页面基础布局的一部分:

# 布局与路由

在讨论具体的布局组件设计前,我们首先要解决一个更为基础的问题,那就是如何将布局组件与应用路由结合起来。

下面的这个例子是 react-router 官方提供的侧边栏菜单与路由结合的例子,笔者这里做了一些简化:

const SidebarExample = () => (
  <Router>
    <div style={{ display: "flex" }}>
      <div
        style={{
          padding: "10px",
          width: "40%",
          background: "#f0f0f0"
        }}
      >
        <ul style={{ listStyleType: "none", padding: 0 }}>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/bubblegum">Bubblegum</Link>
          </li>
          <li>
            <Link to="/shoelaces">Shoelaces</Link>
          </li>
        </ul>
      </div>

      <div style={{ flex: 1, padding: "10px" }}>
        {routes.map((route, index) => (
          <Route
            key={index}
            path={route.path}
            exact={route.exact}
            component={route.main}
          />
        ))}
      </div>
    </div>
  </Router>
);

抽象为布局的思想,写成简单的伪代码就是:

<Router>
  <BasicLayout>                   // with sidebar
    {routes.map(route => (
      <Route {...route} />
    ))}
  </BasicLayout>
</Router>

这样的确是一种非常优雅的解决方案,但它的局限性在于无法支持多种不同的布局。受限于一个 Router 只能包含一个子组件,即使我们将多个布局组件包裹在一个容器组件中,如:

<Router>
  <div>
    <BasicLayout>                 // with sidebar
      {routes.map(route => (
      	<Route {...route} />
      )}
    </BasicLayout>
    <FlexLayout>                  // with footer
      {routes.map(route => (
        <Route {...route} />
      )}
    </FlexLayout>
  </div>
</Router>

路由在匹配到 FlexLayout 下的页面时,BasicLayout 中的 sidebar 也会同时显示出来,这显然不是我们想要的结果。换个思路,我们可不可以将布局组件当做 children 直接传给更底层的 Route 组件呢?代码如下:

<Router>
  <div>
    {basicLayoutRoutes.map(route => (
      <Route {...route}>
        <BasicLayout component={route.component} />
      </Route>
    ))}
    {flexLayoutRoutes.map(route => (
      <Route {...route}>
        <FlexLayout component={route.component} />
      </Route>
    ))}
  </div>
</Router>

这里我们将不同的布局组件当做高阶组件,相应地包裹在了不同的页面组件上,这样就实现了对多种不同布局的支持。还有一点需要注意的是,react-router 默认会将 matchlocationhistory 等路由信息传递给 Route 的下一级组件,由于在上述方案中,Route 的下一级组件并不是真正的页面组件而是布局组件,因而我们需要在布局组件中手动将这些路由信息传递给页面组件,或者统一改写 Routerender 方法为:

<Route
  render={props => (                 // props contains match, location, history
    <BasicLayout {...props}>          
      <PageComponent {...props} />
    </BasicLayout>
  )}
/>

另外一个可能会遇到的问题是,connected-react-router 并不会将路由中非常重要的 match 对象(包含当前路由的 params 等数据 )同步到 redux store 中,所以我们一定要保证布局及页面组件在路由部分就可以接收到 match 对象,否则在后续处理页面页眉等与当前路由参数相关的需求时就会变得非常麻烦。

# 页眉 & 页脚

解决了与应用路由相结合的问题,具体到布局组件内部,其中最重要的两部分就是页面的页眉和页脚部分,而页眉又可以分为应用页眉与页面页眉两部分。

应用页眉指的是整个应用层面的页眉,与具体的页面无关,一般来说会包含用户头像、通知栏、搜索框、多语言切换等这些应用级别的信息与操作。页面页眉则一般来讲会包含页面标题、面包屑导航、页面通用操作等与具体页面相关的内容。

在以往的项目中,尤其是在项目初期许多开发者因为对项目本身还没有一个整体的认识,很多时候会倾向于将应用页眉做成一个展示型组件并在不同的页面中直接调用。这样做当然有其方便之处,比如说页面与布局之间的数据同步环节就被省略掉了,每个页面都可以直接向页眉传递自己内部的数据。

但从理想的项目架构角度来讲这样做却是一个反模式(anti-pattern)。因为应用页眉实际是一个应用级别的组件,但按照上述做法的话却变成了一个页面级别的组件,伪代码如下:

<App>
  <BasicLayout>
    <PageA>
      <AppHeader title="Page A" />
    </PageA>
  </BasicLayout>
  <BasicLayout>
    <PageB>
      <AppHeader title="Page B" />
    </PageB>
  </BasicLayout>
</App>

从应用数据流的角度来讲也存在着同样的问题,那就是应用页眉应该是向不同的页面去传递数据的,而不是反过来去接收来自页面的数据。这导致应用页眉丧失了控制自己何时 rerender(重绘) 的机会,作为一个纯展示型组件,一旦接收到的 props 发生变化页眉就需要进行一次重绘。

另一方面,除了通用的应用页眉外,页面页眉与页面路由之间是有着严格的一一对应的关系的,那么我们能不能将页面页眉部分的配置也做到路由配置中去,以达到新增加一个页面时只需要在 config/routes.js 中多配置一个路由对象就可以完成页面页眉部分的创建呢?理想情况下的伪代码如下:

<App>
  <BasicLayout>                    // with app & page header already
    <PageA />
  </BasicLayout>
  <BasicLayout>
    <PageB />
  </BasicLayout>
</App>

# 配置优于代码

在过去关于组件库的讨论中我们曾经得出过代码优于配置的结论,即需要使用者自定义的部分,应该尽量抛出回调函数让使用者可以使用代码去控制自定义的需求。这是因为组件作为极细粒度上的抽象,配置式的使用模式往往很难满足使用者多变的需求。但在企业管理系统中,作为一个应用级别的解决方案,能使用配置项解决的问题我们都应该尽量避免让使用者编写代码。

配置项(配置文件)天然就是一种集中式的管理模式,可以极大地降低应用复杂度。以页眉为例来说,如果我们每个页面文件中都调用了页眉组件,那么一旦页眉组件出现问题我们就需要修改所有用到页眉组件页面的代码。除去 debug 的情况外,哪怕只是修改一个页面标题这样简单的需求,开发者也需要先找到这个页面相对应的文件,并在其 render 函数中进行修改。这些隐性成本都是我们在设计企业管理系统解决方案时需要注意的,因为就是这样一个个的小细节造成了本身并不复杂的企业管理系统在维护、迭代了一段时间后应用复杂度陡增。理想情况下,一个优秀的企业管理系统解决方案应该可以做到 80% 以上非功能性需求变更都可以使用修改配置文件的方式解决。

# 配置式页眉

import { matchRoutes } from 'react-router-config';

// routes config
const routes = [{
  path: '/outlets',
  exact: true,
  permissions: ['admin', 'user'],
  component: Outlets,
  unauthorized: Unauthorized,
  pageTitle: '门店管理',
  breadcrumb: ['/outlets'],
}, {
  path: '/outlets/:id',
  exact: true,
  permissions: ['admin', 'user'],
  component: OutletDetail,
  unauthorized: Unauthorized,
  pageTitle: '门店详情',
  breadcrumb: ['/outlets', '/outlets/:id'],
}];

// find current route object
const pathname = get(state, 'router.location.pathname', '');
const { route } = head((matchRoutes(routes, pathname)));

基于这样一种思路,我们可以在通用的布局组件中根据当前页面的 pathname 使用 react-router-config 提供的 matchRoutes 方法来获取到当前页面 route 对象的所有配置项,也就意味着我们可以对所有的这些配置项做统一的处理。这不仅为处理通用逻辑带来了方便,同时对于编写页面代码的同事来说也是一种约束,能够让不同开发者写出的代码带有更少的个人色彩,方便对于代码库的整体管理。

# 页面标题

renderPageHeader = () => {
  const { prefixCls, route: { pageTitle }, intl } = this.props;

  if (isEmpty(pageTitle)) {
    return null;
  }

  const pageTitleStr = intl.formatMessage({ id: pageTitle });
  return (
    <div className={`${prefixCls}-pageHeader`}>
      {this.renderBreadcrumb()}
      <div className={`${prefixCls}-pageTitle`}>{pageTitleStr}</div>
    </div>
  );
}

# 面包屑导航

renderBreadcrumb = () => {
  const { route: { breadcrumb }, intl, prefixCls } = this.props;
  const breadcrumbData = generateBreadcrumb(breadcrumb);

  return (
    <Breadcrumb className={`${prefixCls}-breadcrumb`}>
      {map(breadcrumbData, (item, idx) => (
        idx === breadcrumbData.length - 1 ?
          <Breadcrumb.Item key={item.href}>
            {intl.formatMessage({ id: item.text })}
          </Breadcrumb.Item>
          :
          <Breadcrumb.Item key={item.href}>
            <Link href={item.href} to={item.href}>
              {intl.formatMessage({ id: item.text })}
            </Link>
          </Breadcrumb.Item>
      ))}
    </Breadcrumb>
  );
}

# 知识点:组件与 redux

在以上的讨论中我们发现布局组件与应用路由之间有着千丝万缕的联系,那么我们能不能抽象出一个通用的布局组件并直接将它作为应用路由的一部分包含在路由中呢?这样开发者在使用这个「高级路由」时,只需要配置每个页面的路由属性就可以自动获得页面的基础布局,岂不是非常方便?

关于这点笔者也做过相关的尝试,但目前得出的结论是布局组件最好还是要做在应用层。因为如果想做一个非展示型的功能强大的布局组件的话,将它 connect 到 redux store 几乎是一件不可避免的事。如果硬要把所有可以从 redux store 中拿到的数据当做 props 再传给抽象出的布局组件的话,props 的设计会非常难做,因为随时的一个需求变更都有可能导致要增加新的 props。这是因为布局组件作为承接全局应用和具体页面之间的一个中间层,需要能够很灵活地向下传递数据。这些数据的处理逻辑都是因具体需求而异的,强行抽象往往会起到适得其反的效果。

延伸来讲,一个组件如果必须要 connect 到 redux store 后才能使用的话,严格意义上来说它就不再是一个组件了,因为这时它就对自己所处的上下文环境有了要求,一旦脱离了当前应用它就无法再顺利地完成自身内部的一些功能,组件所应当拥有的可复用性也就不存在了。

这其中隐含的道理是 UI 与数据在架构时应当是分离的,redux 作为二者之间的粘合剂,一旦二者通过 redux 被连接到了一起,那么这个组件也就不再纯净(pure)了,变为了只隶属于当前项目的一个业务组件且不再具备通用性。一般而言,前端应用中的每一个页面都是这样的业务组件。这又涉及到不同组件的抽象级别,一般而言到了页面这个级别,我们就不再追求组件的通用性,转而更多地追求尽量在组件中简化当前项目内的业务需求,于是将组件 connect 到 redux store 以方便组件获取各个 reducer 中的数据就是可以接受的一种做法了。

# 组合式开发:页面布局

对于常见的前端应用来说,应用是由页面直接通过路由组成的。又因为页面与页面之间是互相平行的关系,所以不同页面之间的共性很难被抽取出来,从而使得应用中出现了大量的重复代码却又因为它们会直接影响到页面的渲染逻辑而无法被删除。

页面布局作为应用与页面之间的连接层很好地缓冲了应用和页面之间的巨大差异,使得我们有了一个恰当的地方来处理同类页面的基础布局以及应用与页面之间的数据交换。举例来说,如果应用中的搜索框只存在于个别几个页面的话,我们就可以为这几个页面专门抽象出一个包含搜索框的布局组件。这样一来,需要搜索框的页面只需要应用这个专用布局即可,不需要搜索框的页面则不需要做任何逻辑判断。相较于在每个页面都需要判断是否渲染搜索框的组件化解法,页面布局的解法显然要优雅得多。

另一方面,页面布局这一层同时也是可插拔的。假设我们现在拿掉了页面布局这一层,具体页面中的渲染逻辑和核心数据并不会受到任何影响,我们可以很方便地将这个页面移植到其他的应用中去,而不需要担心页面和应用之间耦合过深。同理,被抽取出来的布局层也可以轻松地被移植到其他的应用中,而不需要担心其下属页面的具体内容。而在抽掉了页面,布局之后,所留下来的应用层就是上一节中提到的脚手架部分,其中将不包含任何的业务逻辑,可以被应用到任意前端项目。这样一来,应用、布局、页面三者各司其职,在增加了一层抽象后反而更加合理地重新分配了工程复杂度,大大增强了每一层代码的可复用性。

# 小结

在本节中我们从布局组件与应用路由的关系讲起,一起探讨了应用页眉、页面页眉等页面中的通用部分并得出了在企业管理系统搭建中「配置优于代码」的结论。

在下一节中我们将会继续深入探讨企业管理系统中页面级别的权限管理设计。

如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。

阅读全文